Migrate UI provider guards to capability checks#419
Conversation
Two SwiftUI sites gated the "Mark as In Review" affordance with `session.provider == .github`. Replace those guards with `appState.canSetProjectStatus(for: session)`, computed via a capability resolver wired in AppDelegate that consults `TaskBackend.capabilities.contains(.projectBoardStatus)` — UI no longer knows about specific providers (ADR 0005). `GitHubTaskBackend` now declares `.projectBoardStatus`. The capability's contract is loosened to mean "the provider supports project-board status as a UI concept"; the `setTaskStatus` GraphQL migration is still deferred, and execution continues to route through the legacy `IssueTracker.markInReview` path via the unchanged `onMarkInReview` closure until that follow-up lands. Adds `AppStateCapabilityTests` for the new accessor and updates `BackendsTests` to expect the declared capability. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: 9439397C-463C-44E4-BE20-0E6A63303634
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Nicely scoped change — the UI now branches on capability instead of provider identity, the resolver-injection pattern mirrors the existing onMarkInReview / onListWorkspaceRepos closures (CrowUI stays free of a CrowProvider dependency), and the new tests cover the wiring contract well. Verified locally: BackendsTests 15/15 and the new AppStateCapabilityTests 5/5 pass.
There is one issue that should be fixed in this round.
Code Quality
🟡 Yellow — Capability contract is now self-contradictory across the codebase
This PR redefines what .projectBoardStatus means (from "setTaskStatus is callable and succeeds" → "the provider supports project-board status as a UI concept") and updates GitHubTaskBackend.swift + BackendsTests.swift accordingly. But the authoritative contract docs that encode the old, strict meaning are left untouched and now directly contradict the implementation:
Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift:33-36— still says: "Capability-gated: only call whencapabilities.contains(.projectBoardStatus). Calling without the capability throwsProviderError.unimplemented." The clear implication (call it with the capability and it works) is now false forGitHubTaskBackend.Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift:45-47— "GatessetTaskStatus. Without this capability that method throws." NowGitHubTaskBackenddeclares the capability and throws.docs/adr/0005-task-and-code-backend-protocols.md:31-37— the canonical example isif taskBackend.capabilities.contains(.projectBoardStatus) { try await taskBackend.setTaskStatus(...) }, described as "the direct replacement forif session.provider == .github." A caller following that exact documented pattern now hits.unimplemented.
This is the precise hazard the comment removed in this PR warned about ("declaring it means a capability-gated caller can call setTaskStatus and expect success. We add the flag when the implementation arrives, not before."). It is not a live bug — the only runtime path routes through onMarkInReview → IssueTracker.markInReview, and no caller gates setTaskStatus on the capability today — so functionally the PR is sound. But the capability now means two contradictory things depending on which file you read, and the next person who migrates markInReview (or adds any capability-gated setTaskStatus caller) will trust the protocol/ADR contract and get a thrown error.
Suggested fix (cheap, same round trip): update the TaskBackend.swift doc comments (lines 33-36 and 45-47) and the ADR 0005 example to reflect the loosened "UI surface" meaning, and note that setTaskStatus callability is not implied by the flag until the GraphQL migration lands. (Alternatively, if the flag is meant to stay strict, gate the UI on a distinct concept instead of overloading .projectBoardStatus — but doc alignment is the lighter path.)
Security Review
Strengths:
- No new attack surface. Change is a UI-gating predicate plus a synchronous capability lookup; no new shell-outs, network calls, input parsing, or credential handling.
- Resolver defaults to
nil→false, so unwired contexts (tests, previews) safely hide the action rather than failing open.
Concerns: None.
Notes (non-blocking, no action required)
- 🟢
AppDelegate.swift:718captures[providerManager]strongly while the sibling closures use[weak tracker]. Fine here —ProviderManageris a leafSendablefactory that does not referenceappState, so no retain cycle — just noting the deliberate asymmetry.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 1 Yellow, 1 Green] findings. The code is correct and tested; the blocker is the protocol/ADR contract docs now contradicting the redefined capability semantics. Align the docs and this is good to merge.
… contract Review on PR #419 flagged that the protocol doc on `setTaskStatus`, the `.projectBoardStatus` enum case, and the ADR 0005 example all still encoded the old strict contract ("declaring the capability means setTaskStatus succeeds"). With `GitHubTaskBackend` now declaring the capability while `setTaskStatus` still throws .unimplemented (legacy IssueTracker.markInReview executes), those docs directly contradicted the implementation and could mislead a future caller of setTaskStatus. Rewrite each to reflect the loosened "UI surface" meaning: the capability gates UI affordances; method callability is a separate concern that may still throw .unimplemented until a backend's implementation lands. Split the ADR's single example into two (UI-gating vs method-calling) so the distinction is explicit. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: 9439397C-463C-44E4-BE20-0E6A63303634
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues
None.
Security Review
Strengths:
- No new external input, network calls, secrets, or shell construction. The change is a pure indirection of an existing UI predicate.
- Resolver guards
session.provider != nilbefore any lookup;taskBackend(for:)is total over theProviderenum and non-throwing, so there is no crash/throw path from the UI.
Concerns:
- None.
Code Quality
- Behavioral equivalence verified. Only
GitHubTaskBackenddeclares.projectBoardStatus;GitLabTaskBackendandStubCorveilTaskBackenddeclare empty capability sets. SoappState.canSetProjectStatus(for:)is true for exactly the sessions the priorsession.provider == .githubguard covered (SessionDetailView.swift:176,SessionListView.swift:303). - No retain cycle. The resolver captures
[providerManager](aletonAppDelegate), notself.ProviderManager.taskBackend(for:)isnonisolated, so it is synchronously callable from the MainActor resolver — noawait, no actor hop. - Loosened capability contract is safe.
.projectBoardStatusnow means "UI surface supported" rather than "setTaskStatusis wired." The only capability consumers are the two UI guards; no production code callssetTaskStatuswhile gating on the capability and expecting success, so the GitHub backend's continued.unimplementedthrow breaks no live caller. Execution still routes throughonMarkInReview→IssueTracker.markInReview. The weakening is documented consistently acrossTaskBackend.swift,GitHubTaskBackend.swift, and ADR 0005, and pinned bytestGitHubTaskBackendSetTaskStatusThrowsUnimplemented. - Doc reference
IssueTracker.swift:2549is accurate (insidemarkInReview).
Verification
swift test --package-path Packages/CrowCore --filter AppStateCapability→ 5/5 pass.swift test --package-path Packages/CrowProvider --filter BackendsTests→ 15/15 pass (flippedtestGitHubTaskBackendDeclaresCapabilitiesassertion green; unimplemented-throw assertion still green).- Full-repo
swift buildfailed only on a missingGhosttyKit.xcframeworkbinary artifact — a local environment issue unrelated to this PR; the touched packages compile and test cleanly.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Approve — driven by [0 Red, 0 Yellow, 0 Green] findings. Behaviorally equivalent, well-tested, and thoroughly documented; the deferred GraphQL migration is correctly scoped out and the legacy execution path is preserved.
Closes #413
Summary
session.provider == .githubinSessionDetailViewandSessionListViewwithappState.canSetProjectStatus(for: session)— UI no longer references provider identity.canSetProjectStatusResolver+canSetProjectStatus(for:)toAppState.AppDelegatewires the resolver viaProviderManager.taskBackend(for:).capabilities.contains(.projectBoardStatus). ADR 0005's capability-driven gating pattern, exactly as recommended..projectBoardStatusonGitHubTaskBackend. The capability's contract is loosened to mean "the provider supports project-board status as a UI concept."setTaskStatusstill throws.unimplemented; execution continues to route throughIssueTracker.markInReviewvia the unchangedonMarkInReviewclosure until the GraphQL migration follow-up lands.testGitHubTaskBackendDeclaresCapabilitiesto expect the declared capability and added newAppStateCapabilityTestscovering the accessor wiring contract (resolver-unset, resolver-false, resolver-true, session passthrough, nil-provider passthrough).Out of scope
IssueTracker.markInReviewrewiring /setTaskStatusGraphQL migration.CrowUITests) tests for the SwiftUI conditionals.Test plan
arch -arm64 swift test --package-path Packages/CrowCore— 210/210 pass, including new capability tests.arch -arm64 swift test --package-path Packages/CrowProvider --filter BackendsTests— 15/15 pass; flipped assertion holds;testGitHubTaskBackendSetTaskStatusThrowsUnimplementedstill green.arch -arm64 swift build— Build complete.🤖 Generated with Claude Code